contents

왜 코테에서 String 객체를 다루는 방법이 다른가

먼저, 흔한 오해를 바로잡는 것이 중요합니다. BufferedReaderBufferedWriter문자열 조작 자체를 더 빠르게 만들지 않습니다. String 객체를 합치거나 분리하는 등의 연산 속도를 높여주는 것이 아닙니다. 대신, 파일, 네트워크, 콘솔과 같은 소스(source)로부터 문자열을 읽고 쓰는 I/O(입출력) 과정을 극적으로 빠르게 만듭니다.

그 이유는 비용이 비싼 I/O 작업의 횟수를 줄이는 것이라는 한 가지 핵심 개념으로 귀결됩니다.


핵심 문제: 비싼 I/O 호출의 비효율성 🐢

프로그램이 파일에서 읽거나 콘솔에 써야 할 때, 직접 그 작업을 할 수 없습니다. 운영체제(OS)에 작업을 수행해달라고 요청해야 합니다. 이 요청을 시스템 콜(system call) 이라고 합니다.

시스템 콜은 컴퓨터 연산상 비용이 비쌉니다. 시스템 콜이 발생할 때마다 CPU는 컨텍스트 스위치(context switch) 를 수행해야 합니다.

  1. ("사용자 모드"에서 실행 중인) 애플리케이션의 실행을 일시 중지합니다.
  2. OS가 제어권을 갖도록 고도로 권한이 부여된 "커널 모드"로 전환합니다.
  3. OS가 요청된 I/O 작업을 수행합니다(예: 디스크에서 아주 작은 데이터 조각을 읽음).
  4. 다시 커널 모드에서 사용자 모드로 전환합니다.
  5. 애플리케이션 실행을 재개합니다.

이 과정은 상당한 오버헤드를 가집니다. 이제, 기본적인 FileReader를 사용하여 한 번에 한 문자씩 대용량 텍스트 파일을 읽는다고 상상해 보세요.

// 느린 방식
FileReader reader = new FileReader("large_file.txt");
int character;
while ((character = reader.read()) != -1) {
    // 문자 처리
}

이 시나리오에서는, 단 하나의 문자를 읽을 때마다 별도의 비싼 시스템 콜을 발생시킬 수 있습니다. 만약 파일에 백만 개의 문자가 있다면, 백만 번의 컨텍스트 스위치가 발생할 수 있습니다. 이는 엄청나게 비효율적입니다.

비유: 이것은 케이크 재료를 사러 슈퍼마켓에 가는 것과 같습니다. 하지만 모든 재료를 한 번에 사는 대신, 계란 하나를 사러 가게에 갔다가 집에 오고, 다시 밀가루를 사러 갔다가 집에 오고, 또 우유를 사러 가는 식입니다. "가게에 운전해서 가는 것"이 비싼 시스템 콜이고, "재료"가 작은 데이터 조각입니다.


해결책: 버퍼링 (Buffering) 🚀

BufferedReaderBufferedWriter는 기존의 ReaderWriter 객체를 감싸는 래퍼(wrapper) 또는 "데코레이터(decorator)" 역할을 하여 이 문제를 해결합니다. 이들은 메모리에 임시 저장 공간인 버퍼(buffer) 를 도입합니다 (기본적으로 보통 8KB 크기의 char 배열).

BufferedReader의 작동 방식

BufferedReader에게 데이터(예: read()readLine() 호출)를 요청하면, 단순히 요청한 만큼만 가져오지 않습니다.

  1. 내부 버퍼를 완전히 채우기 위해 디스크의 파일과 같은 기본 소스에 단 한 번의 큰 시스템 콜을 합니다.
  2. 많은 양의 데이터를 읽어와 내부 버퍼를 채웁니다.
  3. 그런 다음, 사용자의 read() 요청에 대해 이 빠른 인메모리 버퍼에서 직접 데이터를 제공합니다.
  4. 버퍼가 비워졌을 때만, 버퍼를 다시 채우기 위해 또 다른 비싼 시스템 콜을 합니다.

비유: 이제, 당신은 큰 쇼핑 카트(버퍼)를 가지고 슈퍼마켓에 갑니다. 한 번의 비싼 이동(시스템 콜)으로 카트를 가득 채웁니다. 그 후 하루 종일 재료가 필요할 때마다, 매우 빠른 속도로 집의 식료품 저장실(인메모리 버퍼)에서 그냥 꺼내 쓰기만 하면 됩니다.

BufferedWriter의 작동 방식

BufferedWriter는 반대로 작동합니다. write()를 호출하면, 데이터를 즉시 목적지로 보내지 않습니다.

  1. 문자열 데이터를 메모리 내의 내부 버퍼에 씁니다.
  2. 계속해서 write() 호출이 있을 때마다 버퍼에 데이터를 모읍니다.
  3. 버퍼가 가득 차거나, 사용자가 명시적으로 writer.flush() 또는 writer.close()를 호출했을 때만, 단 한 번의 큰 시스템 콜을 수행하여 버퍼의 전체 내용을 목적지에 씁니다.

실제 코드 예제

파일에 많은 수의 줄을 쓸 때의 차이점을 살펴봅시다.

예제 1: 느린 방식 (직접 I/O)

import java.io.FileWriter;
import java.io.IOException;

public class SlowWriter {
    public static void main(String[] args) {
        try (FileWriter writer = new FileWriter("output_slow.txt")) {
            for (int i = 0; i < 100000; i++) {
                // 각 write 호출이 잠재적으로 시스템 콜을 유발할 수 있음
                writer.write("This is line number " + i + "\n");
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

예제 2: 빠른 방식 (버퍼링된 I/O)

import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;

public class FastWriter {
    public static void main(String[] args) {
        // FileWriter를 BufferedWriter로 감쌉니다.
        try (BufferedWriter writer = new BufferedWriter(new FileWriter("output_fast.txt"))) {
            for (int i = 0; i < 100000; i++) {
                // 먼저 인메모리 버퍼에 씁니다. 빠릅니다!
                writer.write("This is line number " + i);
                writer.newLine(); // 플랫폼에 독립적인 방식으로 줄바꿈을 씁니다.
            }
            // try-with-resources 블록이 writer를 닫을 때 버퍼는 자동으로 flush 됩니다.
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

이 두 예제를 실행하면, FastWriterSlowWriter보다 훨씬 짧은 시간에 완료될 것입니다.

언제 가장 중요한가?

결론

요약하자면, BufferedReaderBufferedWriter는 I/O 성능을 극적으로 향상시키기 때문에 사용해야 합니다. 이들은 운영체제에 대한 느린 시스템 콜의 횟수를 최소화하고, 대신 OS와 효율적인 인메모리 버퍼 간에 대량 읽기/쓰기 작업을 수행함으로써 이를 달성합니다.


왜 C++과 python에는 그런 게 없을까요

자바에 비해 이런 기능이 없는 것처럼 보이는 이유는 개발자에게 그 기능을 제시하는 방식이 다르기 때문입니다.


차이점: 명시적인 래퍼 vs. 스마트한 기본값

각 언어를 자세히 살펴보겠습니다.


🇮🇹 C++: iostream을 통한 기본 버퍼링

C++에서 표준 I/O 라이브러리(<iostream>)와 파일 스트림 라이브러리(<fstream>)는 기본적으로 버퍼링됩니다.

std::cin, std::cout 또는 std::ofstream을 사용하여 파일에 쓸 때, 모든 문자나 줄에 대해 시스템 콜을 하는 것이 아닙니다. 데이터는 먼저 std::streambuf 객체가 관리하는 내부 버퍼에 쓰여집니다.

C++에서 버퍼링 제어하기:

버퍼는 다음과 같은 경우에 자동으로 플러시(flush, OS에 내용을 기록)됩니다.

  1. 버퍼가 가득 찼을 때.
  2. 명시적으로 플러시할 때.
  3. 스트림이 닫힐 때.

이것이 '\n'std::endl 사이에 성능 차이가 나는 이유입니다.

std::endl을 과도하게 사용하면 버퍼의 목적을 무너뜨리고 잦은 시스템 콜을 강제하여 성능을 저해할 수 있습니다.

경쟁 프로그래밍 팁:

C++ 경쟁 프로그래밍에서는 cin과 cout의 속도를 더 높이기 위해 종종 다음 두 줄을 사용합니다.

// cin을 cout과 분리하고, C++ 스트림과 C 표준 스트림의 동기화를 해제합니다.
std::ios_base::sync_with_stdio(false);
std::cin.tie(nullptr);

이는 C++의 버퍼링된 스트림에게 C의 구식 I/O 스트림과 동기화할 필요가 없다고 알려주어 상당한 속도 향상을 가져옵니다.


🐍 파이썬: io 모듈을 통한 기본 버퍼링

파이썬의 I/O 또한 기본적으로 버퍼링됩니다. 내장 함수인 open()을 사용하면 버퍼링된 파일 객체를 얻게 됩니다.

# 'f' 객체는 TextIOWrapper이며, 내부적으로 BufferedWriter와 BufferedReader를 사용합니다.
with open("my_file.txt", "w") as f:
    f.write("이 줄은 먼저 인메모리 버퍼에 쓰여집니다.\n")
# 'with' 블록이 종료될 때 버퍼는 자동으로 플러시되고 파일은 닫힙니다.

파이썬에서 버퍼링 제어하기:

파이썬의 open() 함수는 buffering 인자를 통해 버퍼링을 명시적으로 제어할 수 있게 해줍니다.

경쟁 프로그래밍 팁:

이것이 파이썬에서 input()은 느리고 sys.stdin.readline()은 빠른 이유입니다.


결론: 철학의 차이

결론적으로, 이 기능은 전혀 빠져있지 않습니다. 차이점은 단지 설계 철학에 있습니다.

언어 철학 버퍼를 얻는 방법
자바 명시적인 데코레이션: 기본부터 시작해서 필요한 것을 추가합니다. 기본적인 Reader/WriterBufferedReader/BufferedWriter로 감싸야 합니다.
C++ / 파이썬 스마트한 기본값: 가장 일반적이고 성능 좋은 옵션을 기본으로 제공합니다. 표준 I/O 객체(cout, open())가 이미 버퍼링되어 있습니다.

자바는 성능을 위해 명시적으로 요청하도록 만드는 반면, C++과 파이썬은 기본적으로 성능 좋은 옵션을 제공하고, 정말로 필요할 때만 더 느린 비버퍼링 동작을 명시적으로 요청하도록 만듭니다.

references